Skip to content

feat(audio-io): make IO stages agent-ready (contract + serialization-safe sink) - #2171

Open
shubhamNvidia wants to merge 3 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/io
Open

feat(audio-io): make IO stages agent-ready (contract + serialization-safe sink)#2171
shubhamNvidia wants to merge 3 commits into
NVIDIA-NeMo:mainfrom
shubhamNvidia:agent_pr/io

Conversation

@shubhamNvidia

Copy link
Copy Markdown
Contributor

Summary

Makes the audio IO stages agent-ready (they now implement describe()) and hardens the document sink.

io/convert.py (AudioToDocumentStage) — N:1 sink, AudioTask → DocumentBatch

  • Now configurable via constructor: keep_keys (whitelist of columns to emit; None = all, prior behavior), drop_keys (blacklist), serialize_segments + segments_key.
  • segments (a list of per-segment dicts that may embed waveform tensors) was previously dropped wholesale. With serialize_segments=True it is kept but recursively cleaned by the new _sanitize_nested helper, which walks nested dicts/lists and strips torch.Tensor / audio-blob values (using a _DROP_VALUE sentinel so a legitimate None is preserved).
  • describe() sets Gates(sanitizes_output=True) to mark this as the tensor-safe boundary before a JSON/parquet writer.

io/extract_segments.py (SegmentExtractionStage)

  • Adds describe() with OR-shaped reads covering the extraction combos (timestamp-based vs diarization-based). Marked BATCH_ONLY: extraction runs per batch and appends to a shared metadata file, so process() raises and process_batch() does the work.

Notes for reviewers

  • Backward compatible: keep_keys=None emits all keys as before; segments still dropped unless serialize_segments=True.
  • Worth a look: _sanitize / _sanitize_nested in convert.py (recursive tensor stripping) and the combo auto-detection in extract_segments.py.

…idency resolver

Shared base imported by the audio stage modules to make them agent-ready:
- _agent_ready.py: AgentReady mixin, StageContract/IOSpec/Gates/Role
- _residency.py: input residency resolver (file/waveform/auto)
- common.py: agent-ready helpers (ensure_mono/ensure_waveform_2d)

Excludes the agent orchestration layer (registry/catalog/conformance/planning/agent).
@shubhamNvidia
shubhamNvidia requested a review from a team as a code owner July 7, 2026 15:43
@shubhamNvidia
shubhamNvidia requested review from meatybobby and removed request for a team July 7, 2026 15:43
@copy-pr-bot

copy-pr-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes the audio IO stages agent-ready by adding a describe() contract to each stage via the new AgentReady mixin, hardens AudioToDocumentStage with keep_keys/drop_keys/serialize_segments configurability and a recursive _sanitize_nested helper, and fixes SegmentExtractionStage to accumulate all extracted paths into a list rather than keeping only the last.

  • _agent_ready.py / _residency.py: Two new infrastructure files; _residency.py has a temp-file leak in resolve_audio_path when sf.write raises before register_temp.append is reached.
  • io/convert.py: The _sanitize_nested sentinel (_DROP_VALUE) can be stored directly into the output dict when the top-level segments_key value is a raw tensor (the assignment on line 118 has no is not _DROP_VALUE guard), violating the sanitizes_output=True contract (noted in prior thread).
  • io/extract_segments.py: The describe() contract's reads_one_of covers Combo 2 and 3 but omits Combo 4 (speaker_id present, diar_segments absent), which could mislead an agent planner (noted in prior thread).

Confidence Score: 4/5

Safe to merge with one fix: the temp-file leak in resolve_audio_path should be addressed before _residency.py is exercised in a pipeline that processes waveforms via that path.

The new _residency.py helper creates a temp WAV file and then registers it for cleanup only after sf.write completes; any exception during write or argument evaluation leaves an orphaned file that the caller's cleanup routine will never see. In a long-running pipeline with occasional failures this accumulates stale temp files. The rest of the changes — agent contract types, stage describe() additions, and _sanitize_nested logic — are correct and well-guarded.

nemo_curator/stages/audio/_residency.py — the resolve_audio_path temp-file creation / cleanup ordering needs a fix before this utility is used in production pipelines.

Important Files Changed

Filename Overview
nemo_curator/stages/audio/_agent_ready.py New file defining StageContract, IOSpec, Gates, and the AgentReady mixin — well-structured and self-contained; no functional issues found
nemo_curator/stages/audio/_residency.py New audio residency-resolution helpers; resolve_audio_path leaks the temp file it creates when _as_soundfile_array or sf.write raises before register_temp.append is reached
nemo_curator/stages/audio/common.py Adds AgentReady mixin and describe() to four existing stages; contracts are accurate and backward-compatible
nemo_curator/stages/audio/io/convert.py Extends AudioToDocumentStage with keep_keys/drop_keys/serialize_segments; the _sanitize_nested sentinel can escape into cleaned when segments_key's top-level value is a raw tensor (prior thread)
nemo_curator/stages/audio/io/extract_segments.py Adds describe() to SegmentExtractionStage and fixes output-path accumulation to a list; reads_one_of contract omits Combo 4 (speaker + timestamps) (prior thread); otherwise sound

Class Diagram

%%{init: {'theme': 'neutral'}}%%
classDiagram
    class AgentReady {
        +ClassVar AGENT_STATIC: StaticHints | None
        +ClassVar BATCH_ONLY: bool
        +ClassVar KEY_ROLE_OVERRIDES: Mapping
        +describe() StageContract
        +describe_static()$ StageContract
    }

    class StageContract {
        +reads: IOSpec
        +writes: IOSpec
        +reads_one_of: list[IOSpec]
        +cardinality: Cardinality
        +gates: Gates
        +batch_only: bool
        +dispatch: Dispatch
        +to_dict() dict
    }

    class Gates {
        +writes_to_disk: bool
        +requires_gpu: bool
        +requires_serializable_input: bool
        +sanitizes_output: bool
    }

    class IOSpec {
        +data_keys: list[str]
        +segment_data_keys: list[str]
        +accepts: list[AudioForm]
        +produces: list[ProducedForm]
    }

    class AudioToDocumentStage {
        +BATCH_ONLY = True
        +keep_keys: list[str] | None
        +drop_keys: tuple[str, ...]
        +serialize_segments: bool
        +segments_key: str
        +describe() StageContract
        +_sanitize(data) dict
        +_sanitize_nested(value) object
        +process_batch(tasks) list
    }

    class SegmentExtractionStage {
        +BATCH_ONLY = True
        +output_dir: str
        +output_key: str
        +describe() StageContract
        +process_batch(tasks) list
    }

    AgentReady <|-- AudioToDocumentStage
    AgentReady <|-- SegmentExtractionStage
    StageContract *-- Gates
    StageContract *-- IOSpec
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
classDiagram
    class AgentReady {
        +ClassVar AGENT_STATIC: StaticHints | None
        +ClassVar BATCH_ONLY: bool
        +ClassVar KEY_ROLE_OVERRIDES: Mapping
        +describe() StageContract
        +describe_static()$ StageContract
    }

    class StageContract {
        +reads: IOSpec
        +writes: IOSpec
        +reads_one_of: list[IOSpec]
        +cardinality: Cardinality
        +gates: Gates
        +batch_only: bool
        +dispatch: Dispatch
        +to_dict() dict
    }

    class Gates {
        +writes_to_disk: bool
        +requires_gpu: bool
        +requires_serializable_input: bool
        +sanitizes_output: bool
    }

    class IOSpec {
        +data_keys: list[str]
        +segment_data_keys: list[str]
        +accepts: list[AudioForm]
        +produces: list[ProducedForm]
    }

    class AudioToDocumentStage {
        +BATCH_ONLY = True
        +keep_keys: list[str] | None
        +drop_keys: tuple[str, ...]
        +serialize_segments: bool
        +segments_key: str
        +describe() StageContract
        +_sanitize(data) dict
        +_sanitize_nested(value) object
        +process_batch(tasks) list
    }

    class SegmentExtractionStage {
        +BATCH_ONLY = True
        +output_dir: str
        +output_key: str
        +describe() StageContract
        +process_batch(tasks) list
    }

    AgentReady <|-- AudioToDocumentStage
    AgentReady <|-- SegmentExtractionStage
    StageContract *-- Gates
    StageContract *-- IOSpec
Loading

Comments Outside Diff (1)

  1. nemo_curator/stages/audio/_residency.py, line 502-506 (link)

    P1 Temp-file leak when sf.write raises

    tempfile.mkstemp creates the file and os.close(fd) releases the descriptor before sf.write is called. If _as_soundfile_array raises (e.g., bad waveform shape) or sf.write raises (disk full, unsupported format, soundfile encoding error), the file at tmp exists on disk but is never appended to register_temp, so the caller's cleanup_temp_files never removes it. In a long-running pipeline with periodic failures this accumulates stale files in the temp directory.

    The simplest fix is to append tmp to register_temp before calling sf.write, so the caller's cleanup path sees it regardless of whether the write succeeded.

Reviews (2): Last reviewed commit: "fix(audio): match _residency.py to found..." | Re-trigger Greptile

Comment on lines +57 to +69
def __init__(
self,
batch_size: int = 64,
keep_keys: list[str] | None = None,
drop_keys: tuple[str, ...] = (),
serialize_segments: bool = False,
segments_key: str = "segments",
) -> None:
self.batch_size = batch_size
self.keep_keys = keep_keys
self.drop_keys = drop_keys
self.serialize_segments = serialize_segments
self.segments_key = segments_key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 self.keep_keys is a list[str], so k not in keys is an O(n) scan on every iteration through data.items(). For a data dict with many keys and a non-trivial keep_keys whitelist, this becomes O(data_keys × keep_keys) per call to _sanitize. Since this runs for every task in every batch, converting to a frozenset at construction time is worth the one-time cost.

Suggested change
def __init__(
self,
batch_size: int = 64,
keep_keys: list[str] | None = None,
drop_keys: tuple[str, ...] = (),
serialize_segments: bool = False,
segments_key: str = "segments",
) -> None:
self.batch_size = batch_size
self.keep_keys = keep_keys
self.drop_keys = drop_keys
self.serialize_segments = serialize_segments
self.segments_key = segments_key
def __init__(
self,
batch_size: int = 64,
keep_keys: list[str] | None = None,
drop_keys: tuple[str, ...] = (),
serialize_segments: bool = False,
segments_key: str = "segments",
) -> None:
self.batch_size = batch_size
self.keep_keys = frozenset(keep_keys) if keep_keys is not None else None
self.drop_keys = frozenset(drop_keys)
self.serialize_segments = serialize_segments
self.segments_key = segments_key

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +75 to +85
def describe(self) -> StageContract:
return StageContract(
reads=IOSpec(data_keys=[]),
writes=IOSpec(data_keys=[]),
cardinality="N:1",
# Strips tensors/audio blobs while building the DataFrame, so its
# output is serialization-safe — the sanctioned sink to place before
# a JSON writer when a resident tensor may be present.
gates=Gates(sanitizes_output=True),
description="Aggregate AudioTasks into a DocumentBatch, stripping tensors/audio blobs (JSON/disk-safe).",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 batch_only not reflected in describe() output

BATCH_ONLY = True is set on the class, but the StageContract returned by describe() keeps batch_only=False (the default). The doc comment on StageContract.batch_only says it is "auto-derived at discovery time", so the registry path is correct — but any caller that invokes stage.describe() directly (e.g. a test, a planner that bypasses the registry, or a stage that calls describe() on a sub-stage) receives a contract that says process() is safe to call when it actually raises NotImplementedError. Consider passing batch_only=True here explicitly; the same gap exists in SegmentExtractionStage.describe() and PreserveByValueStage.describe().

Comment on lines +116 to 119
if k in _NON_SERIALIZABLE_KEYS or k == self.segments_key:
if k == self.segments_key and self.serialize_segments:
cleaned[k] = self._sanitize_nested(v)
continue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The _DROP_VALUE sentinel returned by _sanitize_nested is stored into cleaned without a guard check. If segments_key's value happens to be a raw torch.Tensor (rather than the expected list-of-dicts), _sanitize_nested returns _DROP_VALUE and cleaned[k] becomes an unserializable Python sentinel object, which pandas will store as an object-dtype cell. Any downstream JSON or Parquet writer will then fail — silently violating the sanitizes_output=True contract declared in describe().

Suggested change
if k in _NON_SERIALIZABLE_KEYS or k == self.segments_key:
if k == self.segments_key and self.serialize_segments:
cleaned[k] = self._sanitize_nested(v)
continue
if k in _NON_SERIALIZABLE_KEYS or k == self.segments_key:
if k == self.segments_key and self.serialize_segments:
nested = self._sanitize_nested(v)
if nested is not _DROP_VALUE:
cleaned[k] = nested
continue

Comment on lines +332 to +340
def describe(self) -> StageContract:
return StageContract(
reads_one_of=[
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms"], accepts=["file"]),
IOSpec(data_keys=["original_file", "diar_segments", "speaker_id"], accepts=["file"]),
],
writes=IOSpec(data_keys=[self.output_key], produces=["disk"]),
gates=Gates(writes_to_disk=True),
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The reads_one_of contract only covers Combo 2 (timestamp-based) and Combo 3 (diarization with diar_segments + speaker_id), but misses Combo 4 (speaker + timestamps: speaker_id present, diar_segments absent). An agent planning around this contract would incorrectly infer that no speaker_id input is needed for the timestamp combo, or that diar_segments is always required alongside speaker_id.

Suggested change
def describe(self) -> StageContract:
return StageContract(
reads_one_of=[
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms"], accepts=["file"]),
IOSpec(data_keys=["original_file", "diar_segments", "speaker_id"], accepts=["file"]),
],
writes=IOSpec(data_keys=[self.output_key], produces=["disk"]),
gates=Gates(writes_to_disk=True),
)
def describe(self) -> StageContract:
return StageContract(
reads_one_of=[
# Combo 2: timestamp-based extraction (no speaker info)
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms"], accepts=["file"]),
# Combo 3: diarization-based extraction (diar_segments + speaker_id)
IOSpec(data_keys=["original_file", "diar_segments", "speaker_id"], accepts=["file"]),
# Combo 4: speaker + timestamp extraction (speaker_id present, no diar_segments)
IOSpec(data_keys=["original_file", "original_start_ms", "original_end_ms", "speaker_id"], accepts=["file"]),
],
writes=IOSpec(data_keys=[self.output_key], produces=["disk"]),
gates=Gates(writes_to_disk=True),
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant